Optimize page performance
What you'll see
A Docly page (or a display template) scores badly on PageSpeed Insights mobile — typically 60-70 — even though it looks fine to real users. The cause varies from page to page: it might be end-of-body scripts (jQuery/Bootstrap/jarallax/Typed) executing mid-render, render-blocking Google Fonts, an oversized or late-painted hero image, or decorative canvas animations competing for the main thread — often a combination. LCP, FCP and/or TBT suffer. The goal is to diagnose which of these actually applies and fix the score without stripping the site's signature visual effects. This article describes how to do that with Claude Code and the Claude Chrome plugin.
What's actually happening
The point of this article is the workflow, not a fixed list of causes. On a given page the bottleneck could be any of several things — heavy end-of-body JavaScript running mid-render, render-blocking Google Fonts, an oversized or late-painted LCP image, gtag.js, or decorative canvas effects competing for the main thread. Which one dominates varies from page to page, so you should diagnose first, then fix the measured bottleneck rather than assume. On dops.no, deferring the heavy end-of-body JavaScript turned out to be the useful lever, but treat that as one worked example, not a rule.
Use Claude Code to drive the analysis and the edits, and the Claude Chrome plugin to inspect and verify the live page. A typical loop: (1) let Claude read the page source and template structure, (2) use the Chrome plugin to open the page, read the console and network tab, run JS on the page and identify the actual LCP element, (3) measure numerically (see below), (4) have Claude Code apply one targeted change, (5) re-verify in the browser and re-measure.
Measure with the PageSpeed Insights API — not local Lighthouse
This is the most important section in this article. Get measurement wrong and every conclusion that follows is worthless. On dops.no, local Lighthouse reported scores from 47 to 98 on byte-identical code, because it shares CPU with the developer's own browser. Working from those numbers produced five confident, wrong conclusions in a single day before anyone noticed.
Use the PSI API instead. It runs on Google's own infrastructure, takes ~16 seconds per run, and is free (25,000 requests/day). Create a key in Google Cloud Console (APIs & Services → Credentials), restrict it to the PageSpeed Insights API only, and keep it out of the site folder so it is never published:
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=<urlencoded>&strategy=mobile&category=performance&key=$KEY" Without a key you share an anonymous quota and will get HTTP 429.
Cache-bust every run, or you are measuring one run twelve times
The PSI API caches results per URL. Call it twice with the same URL and the second call can return the first call's stored result — same score, same fetchTime. Ask for twelve runs of one URL and you may get one real measurement replayed twelve times, which looks like beautiful, stable, invented data. This is a worse trap than noisy local Lighthouse, because noise at least announces itself.
Demonstrated on dops.no — three calls, same URL:
call 1: score 78 fetchTime 09:19:30.977
call 2: score 73 fetchTime 09:19:13.817
call 3: score 78 fetchTime 09:19:30.977 <-- call 1's result, replayed The fix is a unique query string per run, and a check that it worked:
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=$(urlencode "https://example.com/?cb=$RANDOM")&strategy=mobile&category=performance&key=$KEY" Always verify lighthouseResult.fetchTime is distinct across your runs. If two runs share a timestamp, you have one measurement, not two. Make this assertion part of the harness, not a thing you remember to do:
const unique = new Set(runs.map(r => r.lighthouseResult.fetchTime)).size;
if (unique !== runs.length) throw new Error('Cached results - measurements are not independent'); A related symptom worth knowing: repeated calls to the same URL can also return FAILED_DOCUMENT_REQUEST where cache-busted calls succeed. Distinguish a genuinely cached failure from rate-limiting (a CDN or WAF throttling Google's fetcher) by checking fetchTime again. If Google intermittently cannot fetch your page at all, stop optimising immediately — that breaks indexing and CrUX collection, and matters far more than any score.
Measure all four categories, not just performance
Performance is the loud one, but it is one of four, and the other three are usually cheaper to fix and more durable. Ask for all of them:
&category=performance&category=accessibility&category=seo&category=best-practices They also behave differently from performance, in a way that matters for how you work:
- They are stable. Accessibility, SEO and best-practices are structural checks, not timing measurements. They do not swing between runs, so a single measurement is trustworthy — no 12-run discipline needed. All the caveats above apply to performance only.
- They are pass/fail per audit. A failed audit names the exact element and the exact rule. There is nothing to A/B: fix it and it is fixed.
- Accessibility failures are usually real bugs — unlabelled buttons, poor contrast, missing alt text, broken heading order. Users are actually affected, and in Norway UU is a legal requirement for public-facing sites.
Worth calibrating on: dops.no measures performance 97, accessibility 100, SEO 100, best-practices 100 with zero failed audits in the three structural categories. If your site is not there, that is where the cheap wins are — not in the last few performance points.
Agentic browsing: can an AI agent read the page?
Increasingly, an important visitor is not a person. AI agents, LLM crawlers and answer engines read pages to summarise, cite and recommend them. None of the four Lighthouse categories measures this, so check it separately — and it costs almost nothing on a Docly site, because .hash templates render server-side.
What actually matters:
- Content must exist in the HTML, not be assembled by JavaScript. Fetch the page with
curland read what comes back. If the headings and body text are there, agents can read you. This is where the deferral strategy in this article pays an unexpected dividend: the content never depended on the JavaScript we deferred. - Beware CSS that hides content when JavaScript does not run. Scroll-reveal patterns set
opacity: 0and wait for JS. An agent that renders without executing scripts sees a blank page. Guard the reveal so it only applies when scripting is available:
Without the@media (scripting: enabled) and (prefers-reduced-motion: no-preference) { [data-reveal] { opacity: 0; transition: opacity 1s ease, transform 1s ease; } }scripting: enabledguard, your content is invisible to anything that does not run your JS — and you would never notice in a normal browser. - Structured data (
application/ld+json) tells an agent what the page is.FAQPage,BreadcrumbList,Service,Organizationare cheap to add and machine-unambiguous. Note CSP does not blockld+json— it is data, not executable script. - Semantic structure — one
h1, a sane heading hierarchy, real<main>/<nav>/<section>landmarks, descriptive link text instead of "les mer". This is the same work as the accessibility category; doing it once serves screen readers and agents alike. - Check
robots.txtdeliberately. Whether AI crawlers should be allowed is a business decision, not a default to drift into. Decide it, then write it down.
Scores are bimodal — count fast runs, do not take the median
PSI does not scatter scores randomly around a centre. On dops.no it returned either ~71 or ~98, almost nothing in between. The score tracked one number exactly: the LCP element render delay, which was either ~120ms (→ 98) or ~2000ms (→ 71). Both modes loaded identical resources.
This only becomes visible once you cache-bust; cached runs hide it behind repeated identical numbers. With a bimodal distribution the median of 5 runs is a coin flip, and it will happily tell you an intervention worked when it did nothing. Not every site is bimodal - check yours before assuming. Two rules follow:
- Run at least 12 measurements per variant and compare the fraction of fast runs, not the median.
- A/B against an unchanged control measured in the same batch. Never compare today's numbers against yesterday's.
On dops.no roughly 17% of runs land on the slow path regardless of what the page contains — stripping every effect, the chat widget, analytics and the consent stack left the fast-run rate unchanged at 83%. That residue is Google's measurement infrastructure, not your code. Do not chase it. A single bad PSI score is not a regression; it is one unlucky run in six.
Lighthouse "estimated savings" are modelled, not measured
Lighthouse told dops.no it could save 990ms by removing render-blocking resources from <head>. Actually removing all of them improved LCP by 0.08s and the score by 1 point. Treat every "Est savings" figure as a hypothesis to be A/B tested, never as a work item.
Related: on a page where end-of-body JavaScript is already deferred, third-party scripts cost far less than their size suggests. Removing 245 KB of JavaScript from dops.no (chat widget, Cloudflare Turnstile, Google Analytics, all canvas effects, jarallax and the consent stack) moved the median from 98 to 99. Decorative effects, deferred to idle after load, are effectively free — they burn CPU outside the measurement window. There is no "keep your effects or reach 100" trade-off; that framing was measured and found false.
Verification: cold cache or it did not happen
Source-code scanning does not catch broken layout, unloaded images or dead SVGs — verify in a real browser with the Claude Chrome plugin. But the browser you have been working in is the one place that cannot catch a broken static asset, because it serves the cached previous version. On dops.no a syntax error in boot.js took the live site down for 40 minutes — no jQuery, no Bootstrap, no Swiper, no form submission — while the developer's browser cheerfully showed everything working from cache. Always confirm with a cold-cache load (PSI, Lighthouse, or a cache-busting query string).
What to do
{ "Content": "Diagnose first with Claude Code + the Claude Chrome plugin, and measure with the PSI API (see Explanation) — then apply only the fixes the diagnosis points at. The sections below are candidate fixes, not a mandatory sequence. Whatever you apply, get the Docly-specific traps right, verify with a cold cache, and A/B every change against an unchanged control.
\n\nDocly-specific traps you MUST get right
\nThese apply when you touch templates, head elements, scripts or static assets on a Docly site — generic web-perf guides never mention them. Which ones bite you depends on how the page is built (e.g. the first only applies if the page uses a master template).
\n- \n
- If the page uses a master template,
xdt:Transform=\"Insert\"is mandatory for new head elements. This only applies when the child page declares a master via the<!--#master file=\"…\"-->directive (see the Master directive docs). Child pages merge into the master viaxdt:Transformattributes (Insert/Replace/SetAttributes/Remove), located byxdt:Locator=\"Match(attr)\", otherwise byid, otherwise by tag-path hierarchy. A new element added to<head>withoutxdt:Transform=\"Insert\"(and without matching an existing element) is silently dropped — the preload \"disappears\" and you wrongly conclude it did not help. A malformed directive produces a visible error; a simply-missingxdt:Transformdoes not. On a standalone page (no master directive) you add head elements normally. \n - CSP blocks inline script.
script-srchas no'unsafe-inline', so no inline<script>and no inlineonload=handlers. Everything must live in external.jsfiles. (The font media-toggle trick and the classic async-CSSonloadswap do not work here.) Notestyle-srcdoes allow'unsafe-inline', so inlining critical CSS is possible. \n - Docly minifies HTML and JS. Attribute quotes are dropped (
id=ga-init) and JS function names are renamed (boot→t). Do not rely on inline function names when verifying. \n - Static
/assets/files have no automatic version parameter. When you change a static.js/.css, manually bump?v=in the referencing file, or returning visitors get a stale cached version. Files referenced with no?v=at all (e.g.boot.js) are the dangerous ones — your fix silently does not reach anyone who has visited before. \n
How to work safely on a live site
\n- \n
- Never write JavaScript into production files via shell/node escaping. On dops.no a
node -e \"…replace…\"lost one backslash, turning/^\\/index5/iinto/^/index5/i— a syntax error that stoppedboot.jsparsing and took the whole site's JavaScript down for 40 minutes. Edit files directly, then syntax-check:node -e \"new (require('vm').Script)(require('fs').readFileSync(f,'utf8'))\". \n - Do not put test gates in shared production files. Copy the page to a throwaway test page (
index2.hashetc.) withnoindex,nofollowand change exactly one thing. If a variant needs a different head or master, copy those too rather than branching the real ones. \n - Verify with a cold cache. Your own browser serves the cached previous file and will show a broken site working perfectly. Confirm via PSI/Lighthouse or a cache-busting query string. \n
1. Defer heavy end-of-body JavaScript to after load + idle
\nIf the diagnosis shows end-of-body scripts running mid-render, load heavy libraries later. Load them sequentially (to preserve dependency order: jQuery → Popper → Bootstrap, footer-init.js last) only after load + requestIdleCallback, from one small boot.js:
(function () {\n var scripts = [ /* jQuery, jarallax, popper, bootstrap, swiper, typed,\n smooth-scroll, submitform, dopsfx.js, footer-init.js?v=2 */ ];\n function next(i){ if(i>=scripts.length)return;\n var s=document.createElement('script'); s.src=scripts[i];\n s.onload=s.onerror=function(){next(i+1)}; document.body.appendChild(s); }\n var idle = window.requestIdleCallback || function(cb){return setTimeout(cb,1)};\n if(document.readyState==='complete') idle(function(){next(0)},{timeout:2000});\n else window.addEventListener('load', function(){idle(function(){next(0)},{timeout:2000})}, {once:true});\n})();\nReplace the entire old script block at the bottom of footer.hash with just reveal.js + boot.js. This one file now controls every heavy dependency on the site — treat it accordingly.
2. Self-host the font, remove render-blocking Google Fonts
\nA Google Fonts @import is a render-blocking third-party request on the critical path. Removing it is structurally sound regardless of what any single measurement says, and it removes a third-party call (GDPR win).
- \n
- Download the font as one variable woff2 (a latin subset covers æøå:
unicode-range: U+0000-00FF, …, weight200 900). \n - Put an
@font-facewithfont-display:swapin its own CSS. \n - Remove the Google Fonts
@importfrom the theme CSS. \n - Preload in
<head>.crossoriginis always required for font preloads. \n
@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 200 900;\n font-display: swap;\n src: url('/assets/fonts/inter-latin.woff2') format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;\n}\n<link rel=\"preload\" as=\"font\" type=\"font/woff2\"\n href=\"/assets/fonts/inter-latin.woff2\" crossorigin=\"\"\n xdt:Transform=\"Insert\" />\n\n3. Optimise + preload the LCP (hero) image
\nIdentify the real LCP element in the browser first — do not assume it is the one you expect. Then shrink it and make it discoverable early (dops.no: 1024px/112KB → 768px/30KB webp, built with sharp).
<link rel=\"preload\" as=\"image\" href=\"…-m.webp\"\n fetchpriority=\"high\" media=\"(max-width: 991.98px)\"\n xdt:Transform=\"Insert\" />\n<link rel=\"preload\" as=\"image\" href=\"…-d.webp\"\n fetchpriority=\"high\" media=\"(min-width: 992px)\"\n xdt:Transform=\"Insert\" />\nVerify with the lcp-discovery-insight audit: it should report the resource as eagerly loaded, discoverable in the initial document, and priority-hinted. If that audit is already green, the image is not your problem — look at element render delay instead.
4. Deferring third-party scripts (analytics, chat, consent)
\nSet Consent Mode v2 defaults early and synchronously (so they are queued before any cookie write), and load the gtag.js library itself only after load + idle. Because CSP blocks inline script, both live in external .js files.
Calibrate your expectations. On dops.no, once end-of-body JS was already deferred, removing 245 KB of third-party JavaScript (chat widget, Cloudflare Turnstile, analytics, all canvas effects, jarallax and the consent stack) moved the median score from 98 to 99. Third-party deferral is worth doing for real users on slow connections, and a chat widget that loads on scroll rather than on landing is better product — but do not expect the score to reward it. Decide these on UX grounds, not score grounds.
\n\n5. Defer decorative canvas effects to after load + idle
\nWrap effect components in a boot() that runs after window load + requestIdleCallback, with a prefers-reduced-motion skip. Once deferred this way they are effectively free: on dops.no the effects bundle burned ~1000ms of CPU but cost 0 points, because it runs outside the measurement window. Removing all effects changed the fast-run rate by nothing (83% → 83%).
There is no \"signature effects or a good score\" trade-off. That framing was tested and is false. Keep the effects.
\n\n6. Kill CLS from effect elements
\nIf effect elements jump from inline to absolute when upgraded, pushing layout, give them absolute positioning in CSS from the start (dops.no: CLS 0.032 → 0.005):
dops-fx, .dops-fx {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n}\n\nMistakes to avoid
\n- \n
- Do not conclude from local Lighthouse. It reported 47-98 on identical code on dops.no. Use the PSI API. \n
- Do not call PSI twice with the same URL. It caches per URL and will replay the first result. Cache-bust with a unique query string and assert that
fetchTimediffers across runs. Twelve cached runs look like twelve agreeing measurements and are one. \n - Do not apply the 12-run discipline to accessibility/SEO/best-practices. Those are structural and stable; one run is enough. The variance caveats are performance-only. \n
- Do not conclude from a median of 5 (performance only). The distribution is bimodal; the median is a coin flip. Use 12+ runs and compare fast-run fractions against a control measured in the same batch. \n
- Do not treat one bad PSI score as a regression. ~17% of runs land on the slow path no matter what the page contains. \n
- Do not build a work list from Lighthouse \"Est savings\". A modelled 990ms of render-blocking savings was worth 0.08s when actually removed. \n
- Do not strip effects, chat or analytics to chase points. All of it together is worth ~1 point when properly deferred. \n
- Do not verify in a warm browser. It is the one check that cannot detect a broken static asset. \n
- Do not edit production JS via script escaping. See \"How to work safely on a live site\". \n
Copyable checklist
\n- \n
- Get a PSI API key; restrict it to the PageSpeed Insights API; store it outside the site folder. \n
- Measure the baseline across all four categories (
&category=performance&category=accessibility&category=seo&category=best-practices). Fix failed accessibility/SEO/best-practices audits first - they are stable, cheap and real. \n - For performance only: 12+ PSI runs, each with a unique cache-busting query string. Assert that
fetchTimeis distinct across runs before believing any of them. Record the fast-run fraction, the median, the LCP element and the LCP phase breakdown. \n - If TBT is already low and FCP is fine, the JavaScript is not your problem — look at LCP phases (TTFB / load delay / load duration / element render delay) before touching anything. \n
- Move heavy end-of-body scripts into
boot.js(sequential, after load + idle). Tidyfooter.hash. \n - Self-host the font; remove the Google Fonts
@import; preload the woff2 withcrossorigin. \n - Shrink + preload the LCP image (
fetchpriority=high, media-scoped;xdt:Transform=\"Insert\"on master-templated pages). \n - Defer
gtag.js; keep consent defaults synchronous and their ordering intact. \n - Defer decorative effects to load + idle with a
prefers-reduced-motionskip. Pin themposition:absoluteto kill CLS. \n - Bump
?v=on every changed static/assets/file. \n curlthe page and confirm the headings and body text are present in the raw HTML - that is what AI agents and crawlers read. Check any scroll-reveal CSS is guarded by@media (scripting: enabled), or the content is invisible without JS. \n- Verify cold-cache: scripts actually load, no broken images, effects run, jQuery/Swiper/Typed work, NOINDEX preserved where required. \n
- Re-measure with 12+ runs and compare against the control. If the fast-run fraction did not move, the change did nothing — revert it rather than keeping it \"just in case\". \n
See also Cache-busting bundles with assetUrl, Use AVIF and WebP image formats, Restrict and register image sizes, and Use master pages for shared layout.
\n\nRelevant documentation: docly.assetUrl() (cache-busting bundle URLs), docly.setHeader() (response headers / caching), and the Master directive (master templates and xdt:Transform).